iT邦幫忙

2025 iThome 鐵人賽

DAY 21
1

每天手動刷新求職平台,在一堆職缺中尋找符合條件的目標,是不是讓你感到厭煩?
這篇透過自動化工具 n8n,打造一個專屬的職缺小助理,讓它每天定時幫你從 104 人力銀行撈取最新的職缺,並整理好後自動發送到你的 Discord

workflow

Step 1:設定排程觸發

  • 來到儀表板,新增一個流程「Create Workflow」

    image 0.png

  • 初始節點選擇排程「On a schedule」

    image 1.png

  • 設定想收到通知的時間,比如說每日早上 9 點

    image 2.png

  • 接下來到「設定」裡面調整時區

    image 3.png

  • 把時區的「Timezone」設定為台灣時間

    image 4.png

Step 2:抓取 104 職缺資料

  • 接著去 104 搜尋職缺,並把網址複製下來,比如說「react、台北、新北、今日更新」的網址可能會長這樣

    https://www.104.com.tw/jobs/search/?area=6001001000,6001002000&jobsource=joblist_search&keyword=react&mode=s&page=1&order=15&isnew=0&searchJobs=1
    
  • 接著我們把網址改成像這樣的格式

    https://www.104.com.tw/jobs/search/api/jobs?area=6001001000%2C6001002000&isnew=0&jobsource=m_joblist_search&keyword=react&mode=s&order=15&page=1&pagesize=20&searchJobs=1
    
  • 回到 n8n,新增節點「HTTP Request」,方法「GET」,URL 貼上剛剛的網址,然後把「Send Headers」打開,填上底下的資料

    • Name:

      User-Agent
      
      • Value:

        Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
        
    • Name:

      Referer
      
      • Value: 使用原本第一次複製的網址

        https://www.104.com.tw/jobs/search/?area=6001001000,6001002000&jobsource=m_joblist_search&keyword=react&mode=s&page=1&order=15&searchJobs=1&isnew=0
        

    image 5.png

Step 3:用程式碼整理與篩選職缺

從 API 拿到的資料很雜,我們需要用程式碼來篩選出我們真正想要的,並將其整理成美觀的格式

  • 接著我們用「Code」節點來整理資料

    image 6.png

  • 程式碼填寫如下

    const apiResult = items[0].json;
    
    // --- 可調整的設定 ---
    // Discord 字元限制,我們設定一個保守值以預留空間給頁首頁尾
    const DISCORD_CHAR_LIMIT = 1950;
    // --------------------
    
    // 1. 取得職缺列表
    const allJobs = apiResult.data;
    
    if (!Array.isArray(allJobs) || allJobs.length === 0) {
      console.log("今天沒有新的職缺。");
      return [];
    }
    
    // 2. 進行篩選 (Filter)
    const filteredJobs = allJobs.filter((job) => {
      // --- 在這裡加入你的篩選條件 ---
      const today = new Date();
      // 將日期設為台北時區 (UTC+8) 的午夜零時
      today.setHours(today.getHours() + 8);
      const todayString = today.toISOString().slice(0, 10).replace(/-/g, "");
    
      const isToday = job.appearDate === todayString;
      const salaryIsOk = job.salaryLow >= 50000;
    
      return isToday && salaryIsOk;
    });
    
    if (filteredJobs.length === 0) {
      console.log("有抓到職缺,但沒有符合篩選條件的。");
      return [];
    }
    
    // 3. 將篩選後的職缺進行格式化 (Map)
    const allFormattedMessages = filteredJobs.map((job) => {
      const formatSalary = (low, high) => {
        if (low === 0 && high === 0) return "面議";
        if (high === 9999999) return `${low.toLocaleString()} 以上`;
        return `${low.toLocaleString()} - ${high.toLocaleString()}`;
      };
    
      const jobName = job.jobName;
      const jobLink = job.link.job;
      const custName = job.custName;
      const custLink = job.link.cust;
      const location = job.jobAddrNoDesc;
      const salary = formatSalary(job.salaryLow, job.salaryHigh);
    
      return `**職缺:** [${jobName}](${jobLink})\n**公司:** [${custName}](${custLink})\n**地區:** ${location}\n**薪資:** ${salary}`;
    });
    
    // 4. 處理長度限制並組合最終訊息
    let currentLength = 0;
    const includedMessages = [];
    const separator = "\n\n---\n\n";
    
    // 頁首會用到總數,所以先宣告
    const header = `🔥 今天共有 ${filteredJobs.length} 個符合條件的新職缺!\n\n`;
    currentLength += header.length;
    
    for (const message of allFormattedMessages) {
      // 預計算加入下一則訊息後的總長度
      // 如果是第一則訊息,則不用加分隔線的長度
      const potentialLength =
        currentLength +
        (includedMessages.length > 0 ? separator.length : 0) +
        message.length;
    
      if (potentialLength <= DISCORD_CHAR_LIMIT) {
        includedMessages.push(message);
        currentLength = potentialLength;
      } else {
        // 長度已達上限,停止加入更多訊息
        break;
      }
    }
    
    // 5. 組合最終訊息 (包含頁首和可能的頁尾)
    let finalMessage = header + includedMessages.join(separator);
    
    // 如果有職缺因為長度限制被捨棄,就加上頁尾提示
    const omittedCount = filteredJobs.length - includedMessages.length;
    if (omittedCount > 0) {
      const footer = `\n\n...還有 ${omittedCount} 則職缺因長度限制未顯示。`;
      finalMessage += footer;
    }
    
    return [
      {
        json: {
          discordMessage: finalMessage,
        },
      },
    ];
    

Step 4:傳送 Discord 通知

  • 下個節點選擇 Discord 的「Send a message」

    image 7.png

  • 「Connection Type」選擇「Webhook」,憑證的串接在之前的文章有撰寫過,這邊就不重複惹

    image 8.png

  • Message 的欄位填寫,接著可以點選右上角的「Excute step」來試跑看看

    {
      {
        $json.discordMessage;
      }
    }
    

    image 9.png

  • Discord 會看到類似這樣的訊息就代表成功囉

    image 10.png

  • 完成後的畫布長這樣,也要記得到上方切換為「Active」來啟用哦

    image 11.png

  • 最後也附上完整的 JSON

    {
      "nodes": [
        {
          "parameters": {
            "rule": {
              "interval": [
                {
                  "triggerAtHour": 9
                }
              ]
            }
          },
          "type": "n8n-nodes-base.scheduleTrigger",
          "typeVersion": 1.2,
          "position": [0, 0],
          "id": "f2761f57-78fc-45d7-9823-be17de31c7ba",
          "name": "Schedule Trigger"
        },
        {
          "parameters": {
            "url": "https://www.104.com.tw/jobs/search/api/jobs?area=6001001000%2C6001002000&isnew=0&jobsource=m_joblist_search&keyword=react&mode=s&order=15&page=1&pagesize=20&searchJobs=1",
            "sendHeaders": true,
            "headerParameters": {
              "parameters": [
                {
                  "name": "User-Agent",
                  "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
                },
                {
                  "name": "Referer",
                  "value": "https://www.104.com.tw/jobs/search/?area=6001001000,6001002000&jobsource=m_joblist_search&keyword=react&mode=s&page=1&order=15&searchJobs=1&isnew=0"
                }
              ]
            },
            "options": {}
          },
          "type": "n8n-nodes-base.httpRequest",
          "typeVersion": 4.2,
          "position": [220, 0],
          "id": "8c659e5b-aa5c-47c3-a893-66e5562118ba",
          "name": "HTTP Request"
        },
        {
          "parameters": {
            "jsCode": "const apiResult = items[0].json;\n\n// --- 可調整的設定 ---\n// Discord 字元限制,我們設定一個保守值以預留空間給頁首頁尾\nconst DISCORD_CHAR_LIMIT = 1950; \n// --------------------\n\n\n// 1. 取得職缺列表\nconst allJobs = apiResult.data;\n\nif (!Array.isArray(allJobs) || allJobs.length === 0) {\n  console.log(\"今天沒有新的職缺。\");\n  return []; \n}\n\n// 2. 進行篩選 (Filter)\nconst filteredJobs = allJobs.filter(job => {\n  // --- 在這裡加入你的篩選條件 ---\n  const today = new Date();\n  // 將日期設為台北時區 (UTC+8) 的午夜零時\n  today.setHours(today.getHours() + 8);\n  const todayString = today.toISOString().slice(0, 10).replace(/-/g, '');\n  \n  const isToday = job.appearDate === todayString;\n  const salaryIsOk = job.salaryLow >= 50000; \n  \n  return isToday && salaryIsOk; \n});\n\nif (filteredJobs.length === 0) {\n    console.log(\"有抓到職缺,但沒有符合篩選條件的。\");\n    return [];\n}\n\n// 3. 將篩選後的職缺進行格式化 (Map)\nconst allFormattedMessages = filteredJobs.map(job => {\n  const formatSalary = (low, high) => {\n    if (low === 0 && high === 0) return \"面議\";\n    if (high === 9999999) return `${low.toLocaleString()} 以上`;\n    return `${low.toLocaleString()} - ${high.toLocaleString()}`;\n  };\n\n  const jobName = job.jobName;\n  const jobLink = job.link.job;\n  const custName = job.custName;\n  const custLink = job.link.cust;\n  const location = job.jobAddrNoDesc;\n  const salary = formatSalary(job.salaryLow, job.salaryHigh);\n\n  return `**職缺:** [${jobName}](${jobLink})\\n**公司:** [${custName}](${custLink})\\n**地區:** ${location}\\n**薪資:** ${salary}`;\n});\n\n// 4. 處理長度限制並組合最終訊息\nlet currentLength = 0;\nconst includedMessages = [];\nconst separator = '\\n\\n---\\n\\n';\n\n// 頁首會用到總數,所以先宣告\nconst header = `🔥 今天共有 ${filteredJobs.length} 個符合條件的新職缺!\\n\\n`;\ncurrentLength += header.length;\n\nfor (const message of allFormattedMessages) {\n  // 預計算加入下一則訊息後的總長度\n  // 如果是第一則訊息,則不用加分隔線的長度\n  const potentialLength = currentLength + (includedMessages.length > 0 ? separator.length : 0) + message.length;\n  \n  if (potentialLength <= DISCORD_CHAR_LIMIT) {\n    includedMessages.push(message);\n    currentLength = potentialLength;\n  } else {\n    // 長度已達上限,停止加入更多訊息\n    break;\n  }\n}\n\n// 5. 組合最終訊息 (包含頁首和可能的頁尾)\nlet finalMessage = header + includedMessages.join(separator);\n\n// 如果有職缺因為長度限制被捨棄,就加上頁尾提示\nconst omittedCount = filteredJobs.length - includedMessages.length;\nif (omittedCount > 0) {\n  const footer = `\\n\\n...還有 ${omittedCount} 則職缺因長度限制未顯示。`;\n  finalMessage += footer;\n}\n\nreturn [{\n  json: {\n    discordMessage: finalMessage\n  }\n}];"
          },
          "type": "n8n-nodes-base.code",
          "typeVersion": 2,
          "position": [440, 0],
          "id": "0b600252-5755-4f4f-b348-fdbd8dc823c1",
          "name": "Code"
        },
        {
          "parameters": {
            "authentication": "webhook",
            "content": "={{ $json.discordMessage }}",
            "options": {}
          },
          "type": "n8n-nodes-base.discord",
          "typeVersion": 2,
          "position": [660, 0],
          "id": "18deeae7-0f0b-461b-8306-aa9b4318a359",
          "name": "Discord"
        }
      ],
      "connections": {
        "Schedule Trigger": {
          "main": [
            [
              {
                "node": "HTTP Request",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "HTTP Request": {
          "main": [
            [
              {
                "node": "Code",
                "type": "main",
                "index": 0
              }
            ]
          ]
        },
        "Code": {
          "main": [
            [
              {
                "node": "Discord",
                "type": "main",
                "index": 0
              }
            ]
          ]
        }
      },
      "pinData": {},
      "meta": {
        "templateCredsSetupCompleted": true
      }
    }
    

上一篇
[Day20]_隨機電影推薦機
系列文
告別重複瑣事: n8n workflow 自動化工作實踐21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言